前幾篇,我們已經從「事情的先後順序」一路談到有向圖、Cycle、DAG 與拓樸排序。
例如:
A → B → C
代表:
B依賴A,C又依賴B
這種 dependency 並不只存在於修課、專案流程或 Build Pipeline。
如果你每天都在寫 JavaScript,其實你早就在跟 Dependency Graph 打交道了。
只是它平常長得比較像這樣:
package.json
node_modules/
今天我們就把 Graph 拉回一個非常熟悉的主戰場:
npm 專案
假設我們有一個很簡單的前端專案:
app
├ React
├ Router
└ Library A
└ Library B
光從資料夾或套件名稱看,很容易把它想成:
app
├─ React
├─ Router
└─ Library A
└─ Library B
這看起來非常像一棵 Tree。
但如果我們不要看「資料夾放在哪裡」,而是問:
誰依賴誰?
整件事情就會變成另一個模型。
不過在畫圖之前,要先統一箭頭的方向。
這個系列延續前幾篇的規則:
被依賴的套件 → 使用它的套件
因此 A → B 代表:
B依賴A,必須先有A,B才能使用它
有些資料會把箭頭反過來,從「依賴者」指向「被依賴者」。
兩種畫法都有人使用,所以閱讀 Dependency Graph 時,最重要的是先確認作者採用哪一種方向。
假設 app 直接使用 React、Router 與 Library A,而 Library A 又依賴 Library B。
按照剛才的方向,Graph 會是:
這時每個 package 都可以看成一個 node。
圖中的箭頭都從被依賴的套件指向使用它的套件。
換句話說,npm 專案本身就是一張:
Dependency Graph
先看最簡單的一種 dependency。
假設你的 package.json 是:
{
"dependencies": {
"react": "...",
"some-library": "..."
}
}
那麼對你的 app 來說:
react → app
some-library → app
這些就是:
直接依賴 Direct Dependency
也就是你直接宣告:
我的專案需要這些 package
Graph 可以畫成:
這一層的 dependency 通常最好理解,因為它們就在你的 package.json 裡。
更常遇到的問題是 some-library,本身也可能依賴別的 package。
例如:
Library B → Library A → app
雖然你的 package.json 裡可能根本沒有寫 Library B,但你的專案最後還是需要它。
因為只要 Library A 需要 Library B,那麼 app 間接也會受到 Library B 的影響。
這種關係通常稱為:
間接依賴 Transitive Dependency
可以把它們放在一起看:
Library B → Library A → app
對 app 來說,Library A 是直接依賴 direct dependency。
而 Library B 則是間接依賴 transitive dependency。
你沒有直接指定 Library B,但它仍然存在於你的 dependency graph 裡。
這也是為什麼一個看起來只有十幾個 dependencies 的專案,最後可能安裝出數百甚至上千個 packages。
你以為只是安裝了幾個 package,但實際上是:
我選擇的 package,又分別帶進了多少 dependency
實際的情況可能是:
而 B、C、E 又可能各自依賴其他 package:
如果再繼續展開,就會形成一整張 dependency graph,所以 package manager 真正在處理的,不只是一份 package 清單,而是:
一整組彼此連接的 dependency relationship
假設:
Utility → Library A
Utility → Library B
同時 app 又依賴 A 和 B:
Library A → app
Library B → app
整張圖會變成:
這裡的 Utility 同時被兩個 package 使用。
這就是:
Shared Dependency
也就是同一個 dependency 同時連向多個使用它的 node。
注意這個結構已經不是 Tree 了。
因為同一個 Utility 同時參與了兩條 dependency relationship:
Utility → Library A
Utility → Library B
如果硬畫成套件階層,就必須重複放置 Utility,或假裝它只屬於其中一個 package。
Graph 則可以直接保留這種共用關係。
假設你只看 node_modules:
node_modules/
├─ library-a/
├─ library-b/
└─ utility/
它就是一個資料夾階層。
從檔案系統的角度來看:
它當然是一棵 Tree
因為資料夾本來就有 parent / child 的階層關係。
但這不代表:
package 之間的 dependency relationship 也是 Tree
這兩件事情描述的是完全不同的問題。
這裡非常需要分開看,檔案系統描述的是:
這個檔案放在哪個資料夾下面?
例如:
node_modules
└─ utility
這是一種:
包含關係 containment relationship
但 package dependency 描述的是:
誰需要誰?
例如:
Utility → Library A
Utility → Library B
這是一種:
依賴關係 dependency relationship
所以同一批資料,可以同時存在兩種完全不同的結構:
這也再次呼應我們前面一直在談的一件事情:
資料長得一樣,不代表我們應該用同一種方式描述它
要去思考的是:
你現在想表達的是哪一種關係?
因為真實的軟體套件很常共用基礎功能。
例如:
如果你的 app 同時使用:
那可能形成:
更準確一點可以寫成:
React → Router
React → UI Library
React → State Library
Router → app
UI Library → app
State Library → app
React 不是某一個 package 專屬的 child,它是多個 package 都可能共用的前置 dependency。
所以:
package dependency 更接近 Graph,而不是 Tree
事情還可以更複雜,假設:
Utility v1 → Library A
Utility v2 → Library B
那麼表面上它們都叫 Utility,但實際 dependency constraint 並不相同。
例如:
Utility v1
↓
Library A
Utility v2
↓
Library B
package manager 就必須判斷:
這兩個需求能不能共用同一個版本?
如果可以,可能共用。
如果不能,就可能需要同時存在不同版本。
這時真正的 dependency graph 已經不是單純 package name → package name,還包含了 package、version、version constraint 等資訊。
不過今天先不深入 npm 的完整 dependency 解決方案。
我們只需要先建立一個重要直覺:
package manager 面對的是 Graph,不只是資料夾
前面 Day 14 我們談過 A → B → C → A 這叫循環 Cycle。
package dependency 當然也可能發生類似的事情。
例如:
Package A → Package B
Package B → Package A
畫成:
A → B
↑ ↓
└───┘
這就是 circular dependency。
在 JavaScript 開發裡,你可能也看過類似的 module dependency:
// a.js
import { b } from "./b.js";
// b.js
import { a } from "./a.js";
形成:
a.js → b.js
↑ ↓
└───────┘
這裡要注意:
package dependency 與 JavaScript module dependency 是不同層級的 Graph
一個是 package → package,另一個是 module → module,但它們背後面對的結構問題其實非常類似:
dependency 會不會形成循環?
上一篇我們談到:
如果 dependency graph 是 DAG,就可以透過拓樸排序找到符合依賴約束的順序
所以很容易想到 B → A → app 是不是應該:
先裝 B
再裝 A
最後處理 app
這個直覺是正確的,dependency graph 確實可以幫助我們理解:
哪些東西依賴哪些東西
但真實的 npm、pnpm、Yarn 所做的事情比單純拓樸排序複雜得多。
它們還要處理:
version resolution
deduplication
peer dependency
optional dependency
lockfile
package layout
...
等等。
所以這裡不要把:
npm install = Topological Sort
直接畫上等號。
比較精確的說法是:
Dependency Graph 提供了 package manager 必須處理的核心結構
Topological ordering 只是其中一種可以從 Graph 推導出的資訊。
node_modules 只是今天拿來建立直覺的入口。
真正重要的是這件事:
package.json
↓
Dependency Graph
當你看到 A depends on B 時,它不只代表:
安裝
A時還需要B
它其實是在建立一條 B → A 的 edge,當整個系統裡存在大量 dependency 時,我們面對的就不再是一份清單,而是一張 Graph,而且這張 Graph 可能包含:
direct dependency
transitive dependency
shared dependency
cycle
也因此:
node_modules看起來像資料夾 Tree,不代表 package relationship 本身就是 Tree
因為 Tree 描述的是階層,而 Dependency Graph 描述的是依賴。
到目前為止,我們主要用 Dependency Graph 回答:
誰依賴誰,以及誰應該先處理?
但如果系統已經建立好了,某個上游資料突然改變。
這時真正需要解決的問題就變成:
哪些下游結果會受到影響,又必須重新處理?
下一篇,我們就從這個問題開始。